C++0x część 4

C++0x to propozycja nowego standardu języka C++. Została ona zaakceptowana przez komitet ISO 12 sierpnia 2011 roku. Tak oto narodził się C++11. Ponieważ zawiera on dużo zmian i nowości, zarówno po stronie samego języka, jak i biblioteki standardowej, warto się z nimi zapoznać już teraz.


Stopień trudności: 2


Dowiesz się:

  • * Czym są wyrażenia lambda;
  • * Jakie dają możliwości;
  • * Jak je efektywnie wykorzystać w kodzie.

  • Powinieneś wiedzieć:

  • * Jak tworzyć programy w C++;
  • * Co to są obiekty funkcyjne;
  • * Jak używać kompilatora GCC.

  • Wprowadzenie

    W tym odcinku naszego cyklu zajmiemy się kolejną nowością, którą przynosi ze sobą C++11 - wyrażeniami lambda. Dzięki nim możemy tworzyć anonimowe obiekty funkcyjne definiowane w miejscu użycia. Takie podejście poprawia znacznie czytelność kodu, jak również pozwala nie zaśmiecać przestrzeni nazw klasami i funkcjami, które są używane w kodzie tylko raz. Dodatkowo kompaktowa składnia pozwala nieraz zapisać kilka wierszy kodu w kilkunastu znakach.

    Podstawy teoretyczne

    Rachunek lambda (ang. lambda calculus) to system formalny pozwalający definiować funkcje oraz przekazywać im argumenty. Został wprowadzony przez Alonzo Churcha i Stephena Cole'a Kleene'ego w 1930 roku. Rachunek lambda jest zupełny w sensie Turinga, czyli pozwala na rozwiązanie tych samych problemów co maszyna Turinga i stał się inspiracją dla funkcyjnych języków programowania takich jak LISP.

    Rachunek lambda wprowadza pojęcie lambda-wyrażenia (ang. lambda term), które jest definiowane następująco:

    1. zmienna jest lambda wyrażeniem
    2. jeśli t jest lambda wyrażeniem i x jest zmienną to λx.t też jest lambda wyrażeniem
    3. jeśli t i s są lambda wyrażeniami to ts również
    Przy czym punkt 2 nazywany jest często abstrakcją funkcyjną a punkt 3 aplikacją funkcji. Dla poprawienia czytelności można dodać nawiasy. Przykładowe wyrażenia mogą więc wyglądać tak: Intuicyjnie abstrakcja λx.F reprezentuje nienazwaną funkcję, która bierze jeden argument i wstawia go do F. Aplikacja FS z kolei aplikuje argument S do funkcji F. Prosty przykład znajduje się na listingu 1.

    (λx.xzx)y → yzy
    [wstawiamy "y" w miejscu wszystkich x (dlatego że x jest związany przez λ) w wyrażeniu xzx]
    (λb.xzx)y → xzx
    [tutaj zmienna b nie występowała w wyrażeniu]
    Listing 1: Przykładowe aplikacje funkcji do argumentu.

    Anatomia wyrażeń lambda w C++11

    Rachunek lambda wprowadzony w C++11 jest bardzo podobny w założeniach do zaprezentowanej teorii. Notacja jest odrobinę inna między innymi ze względu na brak literki λ na większości klawiatur oraz na pewne rozszerzenia.

    Kompilowanie przykładów
    Do kompilowania przykładów należy użyć kompilatora GCC w wersji 4.6, ustawiając standard języka na C++0x:
    g++-4.6 -Wall -std=c++0x src.cpp
    Większość prezentowanych fragmentów kodu NIE będzie działać na starszych wersjach, które miały minimalne lub żadne wsparcie dla C++0x. W przypadku nowszych wersji GCC niektóre przykłady mogą wymagać drobnych modyfikacji. Pamiętajmy, że w chwili wydania GCC 4.6 C++0x nie był zatwierdzonym standardem. Oprócz tego GCC nie jest jeszcze w pełni zgodny z propozycją standardu języka.

    Listing 2 prezentuje kilka przykładowych lambda wyrażeń w C++11.

    [](int x)->int { return x; } // identyczność
    [](int x, int y)->int { return x+y; } // suma
    [](int x, int y)->int { int z=x+y; return z;} // suma
    Listing 2: Proste wyrażenia lambda w C++11

    Jak widać, żaden z tych przykładów nie pasuje do przedstawionej wcześniej definicji. Identyczność ma podany typ int, co ogranicza jej zastosowanie tylko do liczb całkowitych. Oryginalne wyrażenie mogło być zaaplikowane do czegokolwiek, również do samego siebie. Przykłady 2 i 3 zawierają znak dodawania, który nie występuje na liście dozwolonych symboli. Wyrażenie 3 definiuje nawet tymczasową zmienną z!

    Wyrażenia lambda w C++11, w porównaniu do tych z rachunku lambda, mogą oprócz tego wykonywać dowolne instrukcje, wołać inne funkcje i tworzyć obiekty. Wszystkie te działania mogą być źródłem tzw. efektów ubocznych, na które trzeba bardzo uważać. Oprócz tego wyrażenia lambda w C++11 mogą jeszcze korzystać z zewnętrznych zmiennych, co zostało pokazane w dalszej części artykułu.

    Wyrażenie lambda w C++0x składa się z 3 części.

    Z tych trzech bytów ostatni jest starą dobrą instrukcją blokową, taką jak w pętli for czy warunku if. Dwa pierwsze to nowości wprowadzone w C++11. Każdy z elementów może być pusty, drugi jest dodatkowo opcjonalny.

    Wynika z tego, że minimalne wyrażenie lambda może mieć postać jak na listingu 3. Kod tej postaci wygląda dość ciekawie w C++11, niestety nie robi niczego.

    []{}; // minimalne wyrażenie lambda
    [](){}; // wyrażenie zawierające dodatkowo lambda-declarator

    [](){}(); // zagadka: co to jest?
    Listing 3: Puste wyrażenia lambda

    Zajmiemy się teraz kolejnymi elementami składowymi wyrażenia lambda oraz opiszemy jak i do czego można je wykorzystać.

    Lambda-introducer

    Każde wyrażenie musi zaczynać się parą nawiasów kwadratowych [], które mogą zawierać tzw. lambda-capture, który służy do przekazywania dodatkowych parametrów do wyrażenia. Lambda wyrażenie, może korzystać ze zmiennych, które są zdefiniowane w tym samym zasięgu co samo wyrażenie. Na listingu 4 mamy przedstawione wykorzystanie lambda-capture do przechwytywania zmiennych. Pierwszy przykład nie skompiluje się dlatego, że a nie jest przechwytywane i nie może być użyte wewnątrz wyrażenia. W drugim przykładzie podajemy jawnie, że chcemy przekazać a do wyrażenia, w trzecim mówimy tyle, że ma być przekazane wszystko. Oba przypadki wykorzystują przekazywanie przez wartość, czyli zostaną wywołane operatory kopiowania dla wszystkich obiektów. Przy czym wszystkich oznacza tutaj te które wystąpiły w wyrażeniu. Zmienna b, nie jest kopiowana w przypadku 3 ponieważ nie została użyta.

    int a=2;
    int b=2;
    []{return a+1;}; // gcc: "error: 'a' is not captured"
    [a]{return a+1;}; // OK
    [=]{return a+1;}; // OK
    Listing 4: Przekazywanie przez wartość.

    Wykorzystanie takich zmiennych różni się nieco od podejścia w przypadku funkcji. Gdy funkcja jest wywoływana, podajemy za każdym razem jawnie parametry, muszą one zatem istnieć w chwili wywołania. Jak widać na listingu 5, w momencie wywołania wyrażenia l w cout zmienna a już nie istnieje. Mamy na szczęście jej kopię w wyrażeniu lambda.

    std::function<int ()> l;
    {
      int a=0;
      l=[a]{return a+1;};
    }
    std::cout << l() << std::endl;
    Listing 5: Przekazywanie przez wartość c.d.

    Takie podejście jest czymś pomiędzy funkcją a obiektem. Z jednej strony mamy parametry wywołania jak w przypadku funkcji (o tym za chwilę) z drugiej mamy przechwycone zmienne. Zmiennych przechwyconych przez wartość nie da się modyfikować. Może wydawać się to dziwne i niespójne z wywołaniem funkcji, gdzie możemy robić wszystko z kopią otrzymanych zmiennych. Jeżeli jednak popatrzymy na to od strony bycia stałym obiektem, wydaje się to być dobrym pomysłem.

    W przykładzie z listingu 6 mamy dodane słowo kluczowe mutable, bez którego przykład kompiluje się z błędem. Słowo to oznacza, że nasze wyrażenie nie jest stałe i możemy modyfikować związane z nim zmienne.

    Program z listingu 6 wypisze na ekranie najpierw "0" bo zmienna a zadeklarowana na początku bloku nie uległa zmianie. Później zobaczymy "1 2" lub "2 1" bo kolejność ewaluacji jest tutaj dowolna.

    std::function<int ()> l;
    {
      int a=0;
      l=[a]()mutable{return ++a;};
      std::cout << a << ' ';
    }
    std::cout << l() << ' ' << l() << std::endl;
    Listing 6: Niestałe wyrażenie.

    Oprócz przechwytywania przez wartość mamy również przechwytywanie przez referencję, które oznacza się tradycyjnie ampersandem. Oba rodzaje przechwytywania można ze sobą łączyć, jest to pokazane na listingu 7.

    int a,b;

    []{}; // nie przechwytujemy niczego

    [&a]{}; // a przez referencję
    [&a,b]{};// a przez referencję, b przez wartość
    [=]{}; // wszystko przez wartość
    [&]{}; // wszystko przez referencję
    [=,&a]{};// a przez referencję, reszta przez wartość
    [&,b]{}; // b przez wartość, reszta przez referencję
    Listing 7: Przechwytywanie przez wartość i referencję.

    Możliwości jak widać jest sporo, należy pamiętać tylko o jednym. Jeżeli przechwytywanie jest przez wartość, to mamy kopię obiektu, jeżeli przez referencję to nie. Przenoszenie przez referencję działa więc szybciej, ale przykłady z listingów 5 i 6 nie zadziałają dla referencji. Referencja do nieistniejącego już obiektu a jest niepoprawna i odwołanie się zaskutkuje błędem czasu wykonania, należy na to zwrócić szczególną uwagę.

    Lambda-declarator

    Drugim elementem składowym lambda wyrażenia jest lambda-declarator, przy czym należy zaznaczyć, iż jest on opcjonalny i poprawne wyrażenie lambda może nie zawierać go w ogóle. Lambda-declarator jest postaci:
    (deklaracja parametrów) mutableopt wartość-zwracanaopt
    Nie jest to jeszcze wszystko, lecz pozostałe 2 elementy wykraczają poza zakres tego artykułu. Deklaracja parametrów nie jest opcjonalna, dlatego jak chcieliśmy użyć mutable, w przykładzie z listingu 6, konieczne było dopisanie pustych nawiasów. Deklaracja parametrów wygląda i zachowuje się tak samo, jak ta znana dobrze ze składni funkcji.

    Listing 8 prezentuje przykłady przekazywania argumentów do wyrażenia lambda. Jeden wiersz jest zakomentowany, gdyż się nie kompiluje. Jest to spowodowane próbą pobrania niestałej referencji lvalue do wartości rvalue. Wynik programu przedstawiony jest na listingu 8b.

    int a=1,b=2;
    std::function<int(const int&,int&)> l1=[](const int & x, int &y){y++; return x+y;};
    std::function<int(int,int)> l2=[](int x, int y){return x+y;};
    std::cout << "B: " << b << std::endl;
    std::cout << "l1: " << l1(a,b) << std::endl;
    std::cout << "B: " << b << std::endl;
    std::cout << "l2: " << l2(a,b) << std::endl;
    std::cout << "B: " << b << std::endl;
    //l1(1,2); // ERROR
    l2(1,2); // OK
    Listing 8: Przekazywanie parametrów do wyrażenia.

    B: 2
    l1: 4
    B: 3
    l2: 4
    B: 3
    Listing 8b: Wynik działania programu z listingu 8.

    Zmiana wartości B pomiędzy pierwszym i drugim wypisaniem to zasługa wywołania wyrażenia l1, które wartość tę dostało przez referencję i mogło ją modyfikować. Wywołanie l2, które kopiuje zmienną B, nie zmienia jej oryginalnej wartości.

    Przyszedł teraz czas na rozwiązanie zagadki z wcześniejszej części tego odcinka. Mianowicie - co oznacza zapis "[](){}()". Czytelnik pewnie zauważył, że kilka razy przypisaliśmy lambda wyrażenie do zmiennej. Wykorzystywaliśmy do tego celu szablon std::function z nagłówka functional. Czasami potrzebujemy wyrażenia lambda tylko do jednokrotnego użycia i przypisywanie do nazwanej zmiennej jest nam niepotrzebne. W takim właśnie przypadku możemy wywołać funkcję lambda w miejscu jej definicji. Co więcej, użycie skomplikowanej deklaracji zmiennej za pomocą std::function może być zastąpione użyciem auto. Oba przypadki prezentuje listing 9.

    std::function<void(int)> l1=[](int x){std::cout << "Hello world no " << x << std::endl;}; // przypisanie

    auto l2=[](int x){std::cout << "Hello world no " << x << std::endl;};// przypisanie

    l1(1); // wywołanie
    l2(2); // wywołanie
    [](int x){std::cout << "Hello world no " << x << std::endl;}(3); // wywołanie
    Listing 9: Wywoływanie funkcji lambda.

    Listing 9 dodatkowo pokazuje trzy różne metody wywoływania funkcji lambda. Dwa pierwsze to wywołania na nazwanych obiektach, ostatni prezentuje wywołanie nienazwanego obiektu w miejscu. Inne, dużo bardziej przydatne przykłady wywołania w miejscu prezentuje listing 10.

    std::vector<Item> v={6,5,3,1,2,4}; // Item ma konstruktor z int

    std::sort(v.begin(),v.end(),[](const Item& a, const Item& b){return a.getWeight() < b.getWeight();});

    std::for_each(v.begin(), v.end(), [&v](int n)
    {
      std::cout << "Hello world no " << n << "/" << v.size() << std::endl;
    });
    Listing 10: Wywoływanie funkcji lambda.

    Listing 10 pokazuje nam użycie lambda wyrażenia do sortowania elementów std::vector. Do tej pory w przypadku użycia funkcji std::sort, należało stworzyć klasę z przeciążonym operatorem (), której obiekt potrafił wykonać porównanie. Podejście takie wymagało zwykle kilku linijek kodu i wprowadzania nazwy użytej tylko raz. Dodatkowo miejsce deklaracji było przeważnie odlegle od miejsca użycia. Trudno się nie zgodzić, że użycie funkcji lambda jest dużo czytelniejsze.

    Drugi przykład z listingu 10 pokazuje jeszcze funkcję std::for_each, która dzięki wykorzystaniu lambda wyrażeń pozwala również w czytelny sposób wykonać jakąś akcję dla wszystkich elementów kontenera. Wprawny czytelnik zauważy, że w treści wyrażenia odwołujemy się do v.size(). Możemy tak zrobić dzięki temu, że v jest jawnie przechwytywane przez lambda-capture.

    Ostatnia rzecz tycząca się tego podrozdziału i będąca elementem lambda-declaratora to wartość zwracana. Jak już nie raz było pokazane, lambda funkcje mogą zwracać wartość za pomocą return tak jak zwykłe funkcje. Różnica jest taka, że typ wartości zwracanej nie jest nigdzie zadeklarowany. Standard pozwala nam na jawne zdefiniowanie typu, ale również narzuca na kompilator obowiązek automatycznego jego wyznaczenia w niektórych przypadkach.

    Listing 11 pokazuje różne kombinacje wartości zwracanej przez wyrażenia lambda. Pierwszy przykład pokazuje jawne podanie typu, drugi zostawia to kompilatorowi. Trzeci przypadek to już nadużycie, kompilator ma obowiązek sam ustalić typ zwracany tylko dla wyrażeń typu return "coś", GCC 4.6 potrafi jednak prawidłowo skompilować i ten przypadek. Przykład ze zmienną d już nie zadziała, gdyż mamy niezgodność typów zadeklarowanego i zwracanego. Przykład ze zmienną e w GCC się nie skompiluje, gdyż nie da się ustalić typu zwracanego (przykład ten jest niezgodny ze standardem).

    int a=[](int x, int y)->int {return x+y;}(2,3);
    int a=[](int x, int y) {return x+y;}(2,3);
    int b=[](int x, int y) {if (x%2) return x; else return y;}(2,3);
    int d=[]()->int {return true;}();
    int e=[](int a) {if (a>0) return a; else return false}(2);
    Listing 11: Wartość zwracana.

    Podsumowanie

    Wyrażenia lambda dają nowe możliwości przekazywania kodu jako parametru. Wcześniej było to dużo mniej wygodne (obiekty funkcyjne lub wskaźniki na funkcje). Należy jednak pamiętać o ich specyfice, która zakłada kopiowanie i trzymanie zmiennych z kontekstu do końca życia wyrażenia lambda. O tym, że domyślnie są to obiekty stałe i nie da się ich modyfikować. O tym, że modyfikowalne obiekty mogą mieć inny stan przy każdym wywołaniu. Referencje również wymagają specjalnej uwagi ze względu na możliwość próby odwołania się do nieistniejącego już obiektu. Jeżeli będziemy przestrzegać tych zasad, to wyrażenia lambda będą w naszych rekach bardzo precyzyjnym i silnym narzędziem.

    W kolejnym odcinku cyklu przedstawione zostaną wątki, które po długich bojach zostały w końcu dodane do języka. Co prawda wsparcie dla wątków C++11 ze strony dostawców kompilatorów jest jeszcze niepełne, ale podstawowe mechanizmy już działają. Można zatem zacząć się oswajać z nowym podejściem do wątków, częściowo zapożyczonym jest z biblioteki boost::threads.


    Bartosz Szurgot
    Absolwent Informatyki wydziału Informatyki i Zarządzania Politechniki Wrocławskiej. Obecnie pracuje we Wrocławskim Centrum Sieciowo-Superkomputerowym jako programista. Główne zainteresowania techniczne to: programowanie, Linux, urządzenia wbudowane oraz elektronika. W wolnym czasie tworzy oprogramowanie open-source oraz układy elektroniczne. W C++ programuje od 9 lat.
    Kontakt z autorem: bartek.szurgot@baszerr.org
    Strona domowa autora: http://www.baszerr.org

    Mariusz Uchroński
    Absolwent Elektroniki i Telekomunikacji wydziału Elektroniki Politechniki Wrocławskiej oraz pracownik Wrocławskiego Centrum Sieciowo-Superkomputerowego. Główne nurty zainteresowań technicznych to: programowanie oraz obliczenia HPC, a w szczególności programowanie GPU w CUDA i OpenCL. W C++ programuje od 5 lat.
    Kontakt z autorem: mariusz.uchronski@gmail.com

    Wojciech Waga
    Absolwent Informatyki na wydziale Matematyki i Informatyki Uniwersytetu Wrocławskiego, obecnie słuchacz studiów doktoranckich biologii molekularnej UWr oraz pracownik Wrocławskiego Centrum Sieciowo-Superkomputerowego. Programuje w C++ od 11 lat.
    Kontakt z autorem: wojciech.waga@gmail.com